aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/app/dashboard/bookmarks/[slug]
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-11-23 20:59:34 +0000
committerGitHub <noreply@github.com>2024-11-23 20:59:34 +0000
commit5522e20104da6afe2e4667cf45dbbbbc0e838865 (patch)
tree72f416fa83c97a8533eea431e25bd63bda1e7d81 /apps/mobile/app/dashboard/bookmarks/[slug]
parent4bb74872fd518008afea16a136292037baf5b024 (diff)
downloadkarakeep-5522e20104da6afe2e4667cf45dbbbbc0e838865.tar.zst
ui(mobile): Replace bottom sheet with native screens (#690)
* Remove bottom sheet from bookmark info page * Remove bottom sheet from manage lists page * Remove bottom sheet from new list page * Remove bottom sheet from new bookmark page * Drop bottom-sheets * Improve the look of the modals * Make the search page fade from bottom
Diffstat (limited to 'apps/mobile/app/dashboard/bookmarks/[slug]')
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx296
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx115
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx105
3 files changed, 516 insertions, 0 deletions
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
new file mode 100644
index 00000000..87330a88
--- /dev/null
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
@@ -0,0 +1,296 @@
+import React, { useState } from "react";
+import {
+ Alert,
+ Keyboard,
+ Linking,
+ Pressable,
+ ScrollView,
+ View,
+} from "react-native";
+import ImageView from "react-native-image-viewing";
+import WebView from "react-native-webview";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import BookmarkAssetImage from "@/components/bookmarks/BookmarkAssetImage";
+import BookmarkTextMarkdown from "@/components/bookmarks/BookmarkTextMarkdown";
+import FullPageError from "@/components/FullPageError";
+import { TailwindResolver } from "@/components/TailwindResolver";
+import { Button } from "@/components/ui/Button";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Input } from "@/components/ui/Input";
+import { useToast } from "@/components/ui/Toast";
+import { useAssetUrl } from "@/lib/hooks";
+import { api } from "@/lib/trpc";
+import { ClipboardList, Globe, Info, Trash2 } from "lucide-react-native";
+
+import {
+ useDeleteBookmark,
+ useUpdateBookmarkText,
+} from "@hoarder/shared-react/hooks/bookmarks";
+import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
+
+function BottomActions({ bookmark }: { bookmark: ZBookmark }) {
+ const { toast } = useToast();
+ const router = useRouter();
+ const { mutate: deleteBookmark, isPending: isDeletionPending } =
+ useDeleteBookmark({
+ onSuccess: () => {
+ router.back();
+ toast({
+ message: "The bookmark has been deleted!",
+ showProgress: false,
+ });
+ },
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ },
+ });
+
+ const deleteBookmarkAlert = () =>
+ Alert.alert(
+ "Delete bookmark?",
+ "Are you sure you want to delete this bookmark?",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ onPress: () => deleteBookmark({ bookmarkId: bookmark.id }),
+ style: "destructive",
+ },
+ ],
+ );
+
+ const actions = [
+ {
+ id: "lists",
+ icon: (
+ <TailwindResolver
+ className="text-foreground"
+ comp={(styles) => <ClipboardList color={styles?.color?.toString()} />}
+ />
+ ),
+ shouldRender: true,
+ onClick: () =>
+ router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`),
+ disabled: false,
+ },
+ {
+ id: "open",
+ icon: (
+ <TailwindResolver
+ className="text-foreground"
+ comp={(styles) => <Info color={styles?.color?.toString()} />}
+ />
+ ),
+ shouldRender: true,
+ onClick: () => router.push(`/dashboard/bookmarks/${bookmark.id}/info`),
+ disabled: false,
+ },
+ {
+ id: "delete",
+ icon: (
+ <TailwindResolver
+ className="text-foreground"
+ comp={(styles) => <Trash2 color={styles?.color?.toString()} />}
+ />
+ ),
+ shouldRender: true,
+ onClick: deleteBookmarkAlert,
+ disabled: isDeletionPending,
+ },
+ {
+ id: "browser",
+ icon: (
+ <TailwindResolver
+ className="text-foreground"
+ comp={(styles) => <Globe color={styles?.color?.toString()} />}
+ />
+ ),
+ shouldRender: bookmark.content.type == BookmarkTypes.LINK,
+ onClick: () =>
+ bookmark.content.type == BookmarkTypes.LINK &&
+ Linking.openURL(bookmark.content.url),
+ disabled: false,
+ },
+ ];
+ return (
+ <View>
+ <View className="flex flex-row items-center justify-between px-10 pb-2 pt-4">
+ {actions.map(
+ (a) =>
+ a.shouldRender && (
+ <Pressable
+ disabled={a.disabled}
+ key={a.id}
+ onPress={a.onClick}
+ className="py-auto"
+ >
+ {a.icon}
+ </Pressable>
+ ),
+ )}
+ </View>
+ </View>
+ );
+}
+
+function BookmarkLinkView({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== BookmarkTypes.LINK) {
+ throw new Error("Wrong content type rendered");
+ }
+ return (
+ <WebView
+ startInLoadingState={true}
+ mediaPlaybackRequiresUserAction={true}
+ source={{ uri: bookmark.content.url }}
+ />
+ );
+}
+
+function BookmarkTextView({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== BookmarkTypes.TEXT) {
+ throw new Error("Wrong content type rendered");
+ }
+ const { toast } = useToast();
+
+ const [isEditing, setIsEditing] = useState(false);
+ const initialText = bookmark.content.text;
+ const [content, setContent] = useState(initialText);
+
+ const { mutate, isPending } = useUpdateBookmarkText({
+ onError: () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ });
+ },
+ onSuccess: () => {
+ setIsEditing(false);
+ },
+ });
+
+ return (
+ <View className="flex-1">
+ {isEditing && (
+ <View className="absolute right-0 top-0 z-10 m-4 flex flex-row gap-1">
+ <Button label="Save" variant="default" onPress={Keyboard.dismiss} />
+ <Button
+ label="Discard"
+ variant="destructive"
+ onPress={() => {
+ setContent(initialText);
+ setIsEditing(false);
+ }}
+ />
+ </View>
+ )}
+ <ScrollView className="flex bg-background p-2">
+ {isEditing ? (
+ <Input
+ loading={isPending}
+ editable={!isPending}
+ onBlur={() =>
+ mutate({
+ bookmarkId: bookmark.id,
+ text: content,
+ })
+ }
+ value={content}
+ onChangeText={setContent}
+ multiline
+ autoFocus
+ />
+ ) : (
+ <Pressable onPress={() => setIsEditing(true)}>
+ <View className="mb-4 rounded-xl border border-accent p-2">
+ <BookmarkTextMarkdown text={content} />
+ </View>
+ </Pressable>
+ )}
+ </ScrollView>
+ </View>
+ );
+}
+
+function BookmarkAssetView({ bookmark }: { bookmark: ZBookmark }) {
+ const [imageZoom, setImageZoom] = useState(false);
+ if (bookmark.content.type !== BookmarkTypes.ASSET) {
+ throw new Error("Wrong content type rendered");
+ }
+ const assetSource = useAssetUrl(bookmark.content.assetId);
+ return (
+ <View className="flex flex-1 gap-2">
+ <ImageView
+ visible={imageZoom}
+ imageIndex={0}
+ onRequestClose={() => setImageZoom(false)}
+ doubleTapToZoomEnabled={true}
+ images={[assetSource]}
+ />
+
+ <Pressable onPress={() => setImageZoom(true)}>
+ <BookmarkAssetImage
+ assetId={bookmark.content.assetId}
+ className="h-56 min-h-56 w-full object-cover"
+ />
+ </Pressable>
+ </View>
+ );
+}
+
+export default function ListView() {
+ const { slug } = useLocalSearchParams();
+ if (typeof slug !== "string") {
+ throw new Error("Unexpected param type");
+ }
+
+ const {
+ data: bookmark,
+ error,
+ refetch,
+ } = api.bookmarks.getBookmark.useQuery({ bookmarkId: slug });
+
+ if (error) {
+ return <FullPageError error={error.message} onRetry={refetch} />;
+ }
+
+ if (!bookmark) {
+ return <FullPageSpinner />;
+ }
+
+ let comp;
+ let title = null;
+ switch (bookmark.content.type) {
+ case BookmarkTypes.LINK:
+ title = bookmark.title ?? bookmark.content.title;
+ comp = <BookmarkLinkView bookmark={bookmark} />;
+ break;
+ case BookmarkTypes.TEXT:
+ title = bookmark.title;
+ comp = <BookmarkTextView bookmark={bookmark} />;
+ break;
+ case BookmarkTypes.ASSET:
+ title = bookmark.title ?? bookmark.content.fileName;
+ comp = <BookmarkAssetView bookmark={bookmark} />;
+ break;
+ }
+ return (
+ <CustomSafeAreaView edges={["bottom"]}>
+ <Stack.Screen
+ options={{
+ headerTitle: title ?? "",
+ headerBackTitle: "Back",
+ headerTransparent: false,
+ }}
+ />
+ <View className="flex h-full">
+ {comp}
+ <BottomActions bookmark={bookmark} />
+ </View>
+ </CustomSafeAreaView>
+ );
+}
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
new file mode 100644
index 00000000..5d15ab6b
--- /dev/null
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
@@ -0,0 +1,115 @@
+import React from "react";
+import { Text, View } from "react-native";
+import { Stack, useLocalSearchParams } from "expo-router";
+import TagPill from "@/components/bookmarks/TagPill";
+import FullPageError from "@/components/FullPageError";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Input } from "@/components/ui/Input";
+import { Skeleton } from "@/components/ui/Skeleton";
+import { api } from "@/lib/trpc";
+
+import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
+import { isBookmarkStillTagging } from "@hoarder/shared-react/utils/bookmarkUtils";
+import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
+
+function TagList({ bookmark }: { bookmark: ZBookmark }) {
+ return (
+ <View className="flex flex-row items-center gap-4">
+ <Text className="text-foreground">Tags</Text>
+ {isBookmarkStillTagging(bookmark) ? (
+ <>
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-full" />
+ </>
+ ) : bookmark.tags.length > 0 ? (
+ <View className="flex flex-row flex-wrap gap-2">
+ {bookmark.tags.map((t) => (
+ <TagPill key={t.id} tag={t} />
+ ))}
+ </View>
+ ) : (
+ <Text className="text-foreground">No tags</Text>
+ )}
+ </View>
+ );
+}
+
+function NotesEditor({ bookmark }: { bookmark: ZBookmark }) {
+ const { mutate, isPending } = useUpdateBookmark();
+ return (
+ <View className="flex flex-row items-center gap-4">
+ <Text className="text-foreground">Notes</Text>
+
+ <Input
+ className="flex-1"
+ editable={!isPending}
+ multiline={true}
+ numberOfLines={3}
+ loading={isPending}
+ placeholder="Notes"
+ textAlignVertical="top"
+ onEndEditing={(ev) =>
+ mutate({
+ bookmarkId: bookmark.id,
+ note: ev.nativeEvent.text,
+ })
+ }
+ defaultValue={bookmark.note ?? ""}
+ />
+ </View>
+ );
+}
+
+const ViewBookmarkPage = () => {
+ const { slug } = useLocalSearchParams();
+ if (typeof slug !== "string") {
+ throw new Error("Unexpected param type");
+ }
+ const {
+ data: bookmark,
+ isPending,
+ refetch,
+ } = api.bookmarks.getBookmark.useQuery({ bookmarkId: slug });
+
+ if (isPending) {
+ return <FullPageSpinner />;
+ }
+
+ if (!bookmark) {
+ return (
+ <FullPageError error="Bookmark not found" onRetry={() => refetch()} />
+ );
+ }
+
+ let title = null;
+ switch (bookmark.content.type) {
+ case BookmarkTypes.LINK:
+ title = bookmark.title ?? bookmark.content.title;
+ break;
+ case BookmarkTypes.TEXT:
+ title = bookmark.title;
+ break;
+ case BookmarkTypes.ASSET:
+ title = bookmark.title ?? bookmark.content.fileName;
+ break;
+ }
+ return (
+ <CustomSafeAreaView>
+ <Stack.Screen
+ options={{
+ headerShown: true,
+ headerTitle: title ?? "Untitled",
+ }}
+ />
+ <View className="w-full p-4">
+ <View className="gap-4 px-4">
+ <TagList bookmark={bookmark} />
+ <NotesEditor bookmark={bookmark} />
+ </View>
+ </View>
+ </CustomSafeAreaView>
+ );
+};
+
+export default ViewBookmarkPage;
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
new file mode 100644
index 00000000..b38261df
--- /dev/null
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
@@ -0,0 +1,105 @@
+import React from "react";
+import { FlatList, Pressable, Text, View } from "react-native";
+import Checkbox from "expo-checkbox";
+import { useLocalSearchParams } from "expo-router";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import { useToast } from "@/components/ui/Toast";
+
+import {
+ useAddBookmarkToList,
+ useBookmarkLists,
+ useRemoveBookmarkFromList,
+} from "@hoarder/shared-react/hooks/lists";
+import { api } from "@hoarder/shared-react/trpc";
+
+const ListPickerPage = () => {
+ const { slug: bookmarkId } = useLocalSearchParams();
+ if (typeof bookmarkId !== "string") {
+ throw new Error("Unexpected param type");
+ }
+ const { toast } = useToast();
+ const onError = () => {
+ toast({
+ message: "Something went wrong",
+ variant: "destructive",
+ showProgress: false,
+ });
+ };
+ const { data: existingLists } = api.lists.getListsOfBookmark.useQuery(
+ {
+ bookmarkId,
+ },
+ {
+ select: (data) => new Set(data.lists.map((l) => l.id)),
+ },
+ );
+ const { data } = useBookmarkLists();
+
+ const { mutate: addToList } = useAddBookmarkToList({
+ onSuccess: () => {
+ toast({
+ message: `The bookmark has been added to the list!`,
+ showProgress: false,
+ });
+ },
+ onError,
+ });
+
+ const { mutate: removeToList } = useRemoveBookmarkFromList({
+ onSuccess: () => {
+ toast({
+ message: `The bookmark has been removed from the list!`,
+ showProgress: false,
+ });
+ },
+ onError,
+ });
+
+ const toggleList = (listId: string) => {
+ if (!existingLists) {
+ return;
+ }
+ if (existingLists.has(listId)) {
+ removeToList({ bookmarkId, listId });
+ } else {
+ addToList({ bookmarkId, listId });
+ }
+ };
+
+ const { allPaths } = data ?? {};
+ return (
+ <CustomSafeAreaView>
+ <FlatList
+ className="h-full"
+ contentContainerStyle={{
+ gap: 5,
+ }}
+ renderItem={(l) => (
+ <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-white px-4 py-2 dark:bg-accent">
+ <Pressable
+ key={l.item[l.item.length - 1].id}
+ onPress={() => toggleList(l.item[l.item.length - 1].id)}
+ className="flex w-full flex-row justify-between"
+ >
+ <Text className="text-lg text-accent-foreground">
+ {l.item.map((item) => `${item.icon} ${item.name}`).join(" / ")}
+ </Text>
+ <Checkbox
+ value={
+ existingLists &&
+ existingLists.has(l.item[l.item.length - 1].id)
+ }
+ onValueChange={() => {
+ toggleList(l.item[l.item.length - 1].id);
+ }}
+ />
+ </Pressable>
+ </View>
+ )}
+ data={allPaths}
+ />
+ </CustomSafeAreaView>
+ );
+};
+
+export default ListPickerPage;